LiveView provides comprehensive form bindings for handling user input, validation, and submission with minimal client-side code.
Use phx-change and phx-submit to handle form events:
<.form for={@form} id="my-form" phx-change="validate" phx-submit="save">
<.input type="text" field={@form[:username]} />
<.input type="email" field={@form[:email]} />
<button>Save</button>
</.form>
.form is defined in Phoenix.Component.form/1. The @form assign is created from a changeset via Phoenix.Component.to_form/1.
def mount(_params, _session, socket) do
{:ok, assign(socket, form: to_form(Accounts.change_user(%User{})))}
end
Target specific inputs with their own change events:
<.form for={@form} id="my-form" phx-change="validate" phx-submit="save">
<.input field={@form[:email]}
phx-change="email_changed"
phx-target={@myself} />
</.form>
def handle_event("email_changed", %{"user" => %{"email" => email}}, socket) do
# Handle email change
{:noreply, socket}
end
- Only the individual input is sent in params for inputs with
phx-change
- Inputs with
phx-change must still be within a form element
Error Feedback
LiveView tracks which inputs have been interacted with using _unused_ parameters:
def input(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do
errors = if Phoenix.Component.used_input?(field), do: field.errors, else: []
assigns
|> assign(field: nil, id: assigns.id || field.id)
|> assign(:errors, Enum.map(errors, &translate_error(&1)))
# ... render input with errors
end
Disable sending _unused parameters by adding phx-no-unused-field to inputs or forms.
Number inputs have special handling—LiveView won’t send change events when invalid:
Number inputs have accessibility issues and convert large numbers to exponential notation. Consider using inputmode instead:<input type="text" inputmode="numeric" pattern="[0-9]*">
Password fields require explicit value setting for security:
<.input field={f[:password]} value={input_value(f[:password].value)} />
<.input field={f[:password_confirmation]}
value={input_value(f[:password_confirmation].value)} />
Handle nested associations with .inputs_for:
<.inputs_for :let={fp} field={f[:friends]}>
<.input field={fp[:name]} type="text" />
</.inputs_for>
LiveView supports reactive file uploads with drag-and-drop:
<div class="container" phx-drop-target={@uploads.avatar.ref}>
<.live_file_input upload={@uploads.avatar} />
</div>
See the Uploads guide for details.
Rate Limiting
Control validation frequency with debounce:
Validate when field loses focus:<input type="text" name="user[email]" phx-debounce="blur"/>
Validate after specified milliseconds:<input type="text" name="user[username]" phx-debounce="2000"/>
Forms automatically recover input values after reconnection or crashes:
<form id="wizard" phx-change="validate_wizard_step" phx-submit="save">
<!-- Form fields -->
</form>
Custom Recovery
For stateful forms, provide a custom recovery event:
<form id="wizard"
phx-change="validate_wizard_step"
phx-auto-recover="recover_wizard">
<!-- Form fields -->
</form>
def handle_event("validate_wizard_step", params, socket) do
# Regular validation for current step
{:noreply, socket}
end
def handle_event("recover_wizard", params, socket) do
# Rebuild state based on all input data
{:noreply, socket}
end
Disable automatic recovery with phx-auto-recover="ignore".
Disable LiveReload in development (code_reloader: false) to test form recovery properly.
Use standard reset buttons:
<form id="my-form" phx-change="changed">
<input type="text" name="search" />
<button type="reset" name="reset">Reset</button>
</form>
def handle_event("changed", %{"_target" => ["reset"]} = params, socket) do
# Handle form reset
{:noreply, socket}
end
def handle_event("changed", params, socket) do
# Handle regular form change
{:noreply, socket}
end
Submitting to HTTP Endpoints
Trigger standard HTTP form submission with phx-trigger-action:
<.form :let={f} for={@changeset}
action={~p"/users/reset_password"}
phx-submit="save"
phx-trigger-action={@trigger_submit}>
<!-- Form fields -->
</.form>
def handle_event("save", params, socket) do
case validate_change_password(socket.assigns.user, params) do
{:ok, changeset} ->
{:noreply, assign(socket, changeset: changeset, trigger_submit: true)}
{:error, changeset} ->
{:noreply, assign(socket, changeset: changeset)}
end
end
When phx-trigger-action is true, LiveView disconnects and submits the form to the specified action.
Client Behavior
During phx-change
- Input and form receive
phx-change-loading CSS class
- JavaScript client is the source of truth for focused input values
- Server receives
"_target" param with the triggering input’s keyspace
# For input: <input name="user[username]"/>
%{"_target" => ["user", "username"], "user" => %{"username" => "Name"}}
During phx-submit
- Form inputs set to
readonly
- Submit button disabled
- Form receives
phx-submit-loading class
- Form reactivated
phx-submit-loading class removed
- Last focused input restored
- DOM updates patched
Loading States
phx-disable-with
Change button text during submission:
<button type="submit" phx-disable-with="Saving...">Save</button>
phx-disable-with uses innerText, so nested elements like SVG icons won’t be preserved.
CSS Loading States
Use CSS to show/hide content during submission:
.while-submitting { display: none; }
.inputs { display: block; }
.phx-submit-loading .while-submitting { display: block; }
.phx-submit-loading .inputs { display: none; }
<form id="my-form" phx-change="update">
<div class="while-submitting">Please wait while we save...</div>
<div class="inputs">
<input type="text" name="text" value={@text}>
</div>
</form>
Always include a unique HTML id on forms to prevent focus loss when DOM siblings change.
Triggering Events from JavaScript
Dispatch form events programmatically:
document.getElementById("my-select").dispatchEvent(
new Event("input", {bubbles: true})
)
Prevent submission with a hook:
let Hooks = {}
Hooks.CustomFormSubmission = {
mounted() {
this.el.addEventListener("submit", (event) => {
if (!this.shouldSubmit()) {
event.stopPropagation()
event.preventDefault()
}
})
},
shouldSubmit() {
// Custom validation logic
return true
}
}
<form id="my-form" phx-hook="CustomFormSubmission">
<input type="text" name="text" value={@text}>
</form>
See Also